I was working with a
Vue component. The goal of the component was very simple:- If the URL includes the right query parameters, then it will send a POST request to the backend
- Otherwise, it will throw you to the Generic error page.
Sounds simple, right?. Here is what was done in the component.
<template> <div class="ek-container"> <component :is="WidgetComponents[getWidgetComponents()]" :customer="currentCustomer" :agreement="currentAgreement" :originalAgreement="originalAgreement" :product="currentProduct" :originalProduct="originalProduct" :insuredObject="currentInsuredObject" :originalInsuredObject="originalInsuredObject" :coverage="currentCoverage" :originalCoverage="originalCoverage" :isLoadingDetails="isLoadingDetails" :errorDetails="errorDetailsComputed" /> </div> </template> <script> function getWidgetComponents() { const productCode = currentProduct.value?.code; if ( !showErrorPage.value && hasValidURLparams.value && hasValidProduct.value ) { if (productCode === ProductCodes.PREGNANCY) { return "PregnancyWidget"; } } return "GenericErrorPage"; } ... other codes </script>
This code defines a Vue.js component template with a container that dynamically renders different widgets based on the product's status and type, specifically checking if it’s a "Pregnancy" product. Here's a breakdown of the code's functionality, as well as positive and negative points:
Code Summary
- Template: A
divcontainer with a dynamic component (<component>) that conditionally renders components based ongetWidgetComponents(). It passes several props (customer,agreement,product, etc.) to the rendered component.
- Dynamic Component Resolution: The
getWidgetComponents()function determines the component type based on: - Validity of URL parameters (
hasValidURLparams). - Product validity (
hasValidProduct). - Whether
productCodematchesProductCodes.PREGNANCY.
- Conditional Rendering: If conditions are met and the product code is "Pregnancy," the
PregnancyWidgetcomponent is shown; otherwise, it defaults toGenericErrorPage.
Positive Points
- Modular and Reusable: Using dynamic components (
:is) allows flexibility to display different widgets based on business logic, making it highly reusable.
- Error Handling: The fallback
GenericErrorPageprovides a safe default if parameters are invalid or the product doesn’t match.
- Parameterization: Passing specific props (
customer,agreement, etc.) enables flexibility for the child component to use relevant data.
Negative Points
- Tight Coupling of Conditions: The component heavily relies on a specific product code for conditional rendering. Adding new conditions (like other product types) may require more code duplication or refactoring.
- Limited Extensibility: If new product types require new widgets, the
getWidgetComponents()function could become cluttered and harder to maintain.
- Code Organization:
getWidgetComponentsis embedded in the script without proper separation, making it less modular. Extracting it to a helper or computed property would improve readability and testability.
Recommendations
Consider refactoring
getWidgetComponents to make it easier to add new conditions, potentially using a mapping approach for products to widget components. Also, consider modularizing the logic to improve readability and maintainability.In any case, we discard above issues and move head. Assuming, all query parameters are provided, we are supposed to create offer request. That could be done by sending
POST request to backend. Here, is how it was implemented:And
onMounted Layer 1:onMounted(async () => { ciaStore.showInlineError = false; const areRequestSame = areSameURLParamsRequest( ciaStore.lastValidURLParams, props.widgetProps ?? ({} as WidgetProps) ); const isSystemError = ErrorCode.SYSTEM_ERROR.includes( ciaStore.lastErrorCode ); const shouldSendRequest = hasValidURLparams?.value && hasValidProduct.value && (!areRequestSame || isSystemError); if (shouldSendRequest) { ciaStore.$reset(); ciaStore.lastValidURLParams = props.widgetProps ?? ({} as WidgetProps); await createChangeOfferRequest().catch( (errors: Error | any) => { setTimeout(() => { const errorDetails = getErrorMessageDetails( errors?.message ?? "", ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; }, 300); } ); } });
Why is that issue?
- Why do we need
.thenif you’re already usingawait. Theawaitkeyword pauses execution until thecreateChangeOfferRequest()promise resolves or rejects, allowing you to handle the result or error directly without chaining.thenor.catch. However, if you want to catch errors when usingawait, you can use atry...catchblock instead of.catch.
- We are using
setTimeout. We can not trustsetTimeoutas it can lead to a lot of side effects. JavaScript is single-threaded. Meaning, it can only calculate one thing at a time. But now imagine you have asetTimeoutthat is suppose to fire after 10000ms or 10s. So now JS have to keep track of the time passed. But in between that 10s the user might do some interaction with your page. Now JavaScript have to react to them as well. Here is a very nice blog article about why we should be careful with setTimeout.
The component logic seems overly complex for its intended purpose. Let's break down the issues with this implementation:
- The use of multiple nested functions and promise chains makes the code difficult to read and maintain.
- Error handling is scattered across different layers, making it challenging to manage and debug effectively.
- The reliance on
setTimeoutfor error handling introduces potential timing issues and unnecessary delays.
Layer 2: And then defining the function
createChangeOfferRequestasync function createChangeOfferRequest() { if ( props.widgetProps?.agreementId && props.widgetProps?.customerNo ) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; await ciaStore.createChangeOffer(payload); } }
What is the issue here?
- First of all, why do we need this second layer of function?
Layer 3: And then,
ciaStore which is a Pinia store. export const changeOfferActions = { async createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<void> { const state = useChangeOfferStore(); try { state.isFullscreenLoading = true; const changeOfferResponse = await createChangeOffer( changeOfferRequest ); updateEntities( state.originalEntities, changeOfferResponse ); updateEntities(state.entities, changeOfferResponse); state.offerId = changeOfferResponse.agreement?.offerNo ?? ''; state.customerId = changeOfferRequest.customerId ?? ''; } catch (error) { logInDev('Error creating change offer:', error); throw error; } state.isFullscreenLoading = false; }, .... };
Why could this be an issue?
- Why do we need this third layer?
- Issue:
state.isFullscreenLoadingis set tofalseonly after thetry...catchblock, meaning it will remaintrueif an error occurs.
- Encapsulated State Updates: Keeping state mutation logic in dedicated state functions, like
updateEntities, isolates side effects and improves testability.
Layer 4: Actual
createChangeOffer method.export async function createChangeOffer( changeOfferRequest: ChangeOfferRequest ): Promise<Offer> { return new Promise(async (resolve, reject) => { await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }) .then((responseFromServer) => { if (responseFromServer.error) { reject(responseFromServer.error); } else { resolve(responseFromServer.data as Offer); } }) .catch((error) => { if ( error instanceof TypeError && error.message === "Failed to fetch" ) { reject(new Error(ErrorCode.SYSTEM_ERROR)); } else { reject(error); } }); }); }
Why is issue with this?
- First of all, given the goal of the component, why do we need this?
- Issue: Wrapping
asyncwithinnew Promiseis redundant and unnecessary, asasyncfunctions inherently return promises. This leads to more complex code than needed.
- Issue: The current function handles only a
TypeErrorwith a specific message ("Failed to fetch"). It’s best to broaden the error handling to catch other network-related issues (e.g., 5xx server errors, bad request errors). Solution: Check the response status and structure the error handling to cover more cases.
- Issue: Using
.then()and.catch()within anasyncfunction reduces readability, asawaitwithtry...catchis generally clearer for handling asynchronous code.
Layer 5: The final
`POST` callimport createClient, { Middleware } from "openapi-fetch"; const apiMiddleware: Middleware = { async onRequest({ request }) { try { return request; } catch (error) { throw error; } }, async onResponse({ response }) { return response; } }; export const { POST, GET } = client;
Refactored Version
It felt like too much engineering to me. So, I refactored the code to introduce Tanstack vue query. And this is how my component call looks now:
const { mutate } = useMutation({ mutationFn: createChangeOffer, onSuccess: (response) => { const offerResponse = response.data as Offer; updateEntities( ciaStore.originalEntities, offerResponse ); updateEntities(ciaStore.entities, offerResponse); ciaStore.customerId = props.widgetProps?.customerNo ?? ""; ciaStore.offerId = offerResponse?.agreement?.offerNo ?? ""; ciaStore.requestState = REQUEST_STATE.SUCCESS; }, onError: (error: CustomError) => { const errorDetails = getErrorMessageDetails( error?.messageCode as ErrorCode, ciaStore.commonDrupalText as CommonDrupalText ); errorDetailsRef.value = errorDetails; ciaStore.isFullscreenLoading = errorDetails.errorKey === ErrorKey.SystemErrors; ciaStore.requestState = REQUEST_STATE.ERROR; } }); onMounted(asycn () => { // ... other checks if(valid) { const payload = { policyNumber: props.widgetProps?.agreementId, customerId: props.widgetProps?.customerNo, customerType: "privateCustomer", changeStartDate: moment .utc() .format("YYYY-MM-DDTHH:mm:ss.SSS[Z]") }; mutate(payload); } })
And the
POST call:export const createChangeOffer = async ( changeOfferRequest: ChangeOfferRequest ) => { return await POST("/api/changePolicy/createChangeOffer", { headers: getAPIHeaders(), body: changeOfferRequest }); };
Advantages
- Readability: Instead of a 4/5 layer of function chaining, now it just calls one function.
- With the Tanstack query, I can handle side effects directly without keeping too many variable references.
Original Code Issues
- Unnecessary promise chaining and complex layered structure in the original code
- Potential side effects from using setTimeout in error handling
- Redundant wrapping of async functions within new Promise
- Limited error handling, focusing only on specific TypeError
Refactored Solution
- Introduced Tanstack vue query for improved state management and side effect handling
- Simplified API call structure, reducing function layers
- Enhanced readability by eliminating multiple layers of function chaining
- Improved side effect management without relying on numerous variable references
Another version without third party dependencies
We can create actions in
Pinia. And instead calling different layer of functions, we can directly call this action. For example, we could define action like:import { defineStore } from 'pinia'; import axios from 'axios'; export const useProductStore = defineStore('product', { state: () => ({ products: [], isLoading: false, error: null, }), actions: { async fetchProducts() { this.isLoading = true; this.error = null; try { const response = await axios.get('/api/products'); this.products = response.data; } catch (err) { this.error = err.message; } finally { this.isLoading = false; } }, }, });
And can be called directly:
onMounted(() => { fetchProducts(); });
One of the reason why I prefer Tanstack query is due to the fact that, it handles and provides a lot of state utilities by default. For example, often we need to show loading, pending state, error state. With this library, we do not need to explicitly handle such queries.
Summary
When I refactored this codebase, there was conflict of interest by one of the senior developer. I referred it as technical depth and was strongly opposed by the reviewer saying “I am selling the wrong picture”. May be its easier because I am used to it. That lead me to think, may be its better idea for me to acknowledge myself again, research again, read again and explicitly write down my findings. This article is not mean to blame anyone, this is meant for myself so, that I can educate myself.
Original Implementation Issues
- The
Vuecomponent used excessive promise chaining and complex layered structure, making the code difficult to read and maintain
- Unnecessary use of
setTimeoutfor error handling introduced potential timing issues
- Multiple nested functions and scattered error handling made debugging challenging
Refactored Solution
- Introduced
Tanstack Vue Queryto improve state management and simplify API calls
- Reduced function layers and eliminated multiple layers of function chaining, enhancing readability
- Improved side effect management without relying on numerous variable references
Alternative Approach
- Suggested using
Piniaactions for direct API calls without third-party dependencies
- Tanstack Query preferred for its built-in state utilities, handling loading, pending, and error states automatically
